# typed: strict
# frozen_string_literal: true

require "abstract_command"
require "missing_formula"
require "caveats"
require "options"
require "formula"
require "keg"
require "tab"
require "json"
require "utils/spdx"
require "deprecate_disable"
require "api"

module Homebrew
  module Cmd
    class Info < AbstractCommand
      class NameSize < T::Struct
        const :name, String
        const :size, Integer
      end
      private_constant :NameSize

      VALID_DAYS = %w[30 90 365].freeze
      VALID_FORMULA_CATEGORIES = %w[install install-on-request build-error].freeze
      VALID_CATEGORIES = T.let((VALID_FORMULA_CATEGORIES + %w[cask-install os-version]).freeze, T::Array[String])

      cmd_args do
        description <<~EOS
          Display brief statistics for your Homebrew installation.
          If a <formula> or <cask> is provided, show summary of information about it.
        EOS
        switch "--analytics",
               description: "List global Homebrew analytics data or, if specified, installation and " \
                            "build error data for <formula> (provided neither `$HOMEBREW_NO_ANALYTICS` " \
                            "nor `$HOMEBREW_NO_GITHUB_API` are set)."
        flag   "--days=",
               depends_on:  "--analytics",
               description: "How many days of analytics data to retrieve. " \
                            "The value for <days> must be `30`, `90` or `365`. The default is `30`."
        flag   "--category=",
               depends_on:  "--analytics",
               description: "Which type of analytics data to retrieve. " \
                            "The value for <category> must be `install`, `install-on-request` or `build-error`; " \
                            "`cask-install` or `os-version` may be specified if <formula> is not. " \
                            "The default is `install`."
        switch "--github-packages-downloads",
               description: "Scrape GitHub Packages download counts from HTML for a core formula.",
               hidden:      true
        switch "--github",
               description: "Open the GitHub source page for <formula> and <cask> in a browser. " \
                            "To view the history locally: `brew log -p` <formula> or <cask>"
        switch "--fetch-manifest",
               description: "Fetch GitHub Packages manifest for extra information when <formula> is not installed."
        flag   "--json",
               description: "Print a JSON representation. Currently the default value for <version> is `v1` for " \
                            "<formula>. For <formula> and <cask> use `v2`. See the docs for examples of using the " \
                            "JSON output: <https://docs.brew.sh/Querying-Brew>"
        switch "--installed",
               depends_on:  "--json",
               description: "Print JSON of formulae that are currently installed."
        switch "--eval-all",
               depends_on:  "--json",
               description: "Evaluate all available formulae and casks, whether installed or not, to print their " \
                            "JSON."
        switch "--variations",
               depends_on:  "--json",
               description: "Include the variations hash in each formula's JSON output."
        switch "-v", "--verbose",
               description: "Show more verbose analytics data for <formula>."
        switch "--formula", "--formulae",
               description: "Treat all named arguments as formulae."
        switch "--cask", "--casks",
               description: "Treat all named arguments as casks."
        switch "--sizes",
               description: "Show the size of installed formulae and casks."

        conflicts "--installed", "--eval-all"
        conflicts "--formula", "--cask"
        conflicts "--fetch-manifest", "--cask"
        conflicts "--fetch-manifest", "--json"

        named_args [:formula, :cask]
      end

      sig { override.void }
      def run
        if args.sizes?
          if args.no_named?
            print_sizes
          else
            formulae, casks = args.named.to_formulae_to_casks
            formulae = T.cast(formulae, T::Array[Formula])
            print_sizes(formulae:, casks:)
          end
        elsif args.analytics?
          if args.days.present? && VALID_DAYS.exclude?(args.days)
            raise UsageError, "`--days` must be one of #{VALID_DAYS.join(", ")}."
          end

          if args.category.present?
            if args.named.present? && VALID_FORMULA_CATEGORIES.exclude?(args.category)
              raise UsageError,
                    "`--category` must be one of #{VALID_FORMULA_CATEGORIES.join(", ")} when querying formulae."
            end

            unless VALID_CATEGORIES.include?(args.category)
              raise UsageError, "`--category` must be one of #{VALID_CATEGORIES.join(", ")}."
            end
          end

          print_analytics
        elsif (json = args.json)
          print_json(json, args.eval_all?)
        elsif args.github?
          raise FormulaOrCaskUnspecifiedError if args.no_named?

          exec_browser(*args.named.to_formulae_and_casks.map do |formula_keg_or_cask|
            formula_or_cask = T.cast(formula_keg_or_cask, T.any(Formula, Cask::Cask))
            github_info(formula_or_cask)
          end)
        elsif args.no_named?
          print_statistics
        else
          print_info
        end
      end

      sig { params(remote: String, path: String).returns(String) }
      def github_remote_path(remote, path)
        if remote =~ %r{^(?:https?://|git(?:@|://))github\.com[:/](.+)/(.+?)(?:\.git)?$}
          "https://github.com/#{Regexp.last_match(1)}/#{Regexp.last_match(2)}/blob/HEAD/#{path}"
        else
          "#{remote}/#{path}"
        end
      end

      private

      sig { void }
      def print_statistics
        return unless HOMEBREW_CELLAR.exist?

        count = Formula.racks.length
        puts "#{Utils.pluralize("keg", count, include_count: true)}, #{HOMEBREW_CELLAR.dup.abv}"
      end

      sig { void }
      def print_analytics
        if args.no_named?
          Utils::Analytics.output(args:)
          return
        end

        args.named.to_formulae_and_casks_and_unavailable.each_with_index do |obj, i|
          puts unless i.zero?

          case obj
          when Formula
            Utils::Analytics.formula_output(obj, args:) if obj.core_formula?
          when Cask::Cask
            Utils::Analytics.cask_output(obj, args:) if obj.tap.core_cask_tap?
          when FormulaOrCaskUnavailableError
            Utils::Analytics.output(filter: obj.name, args:)
          else
            raise
          end
        end
      end

      sig { void }
      def print_info
        args.named.to_formulae_and_casks_and_unavailable.each_with_index do |obj, i|
          puts unless i.zero?

          case obj
          when Formula
            info_formula(obj)
          when Cask::Cask
            info_cask(obj)
          when FormulaOrCaskUnavailableError
            # The formula/cask could not be found
            ofail obj.message
            # No formula with this name, try a missing formula lookup
            if (reason = MissingFormula.reason(obj.name, show_info: true))
              $stderr.puts reason
            end
          else
            raise
          end
        end
      end

      sig { params(version: T.any(T::Boolean, String)).returns(Symbol) }
      def json_version(version)
        version_hash = {
          true => :default,
          "v1" => :v1,
          "v2" => :v2,
        }

        raise UsageError, "invalid JSON version: #{version}" unless version_hash.include?(version)

        version_hash[version]
      end

      sig { params(json: T.any(T::Boolean, String), eval_all: T::Boolean).void }
      def print_json(json, eval_all)
        raise FormulaOrCaskUnspecifiedError if !(eval_all || args.installed?) && args.no_named?

        json = case json_version(json)
        when :v1, :default
          raise UsageError, "Cannot specify `--cask` when using `--json=v1`!" if args.cask?

          formulae = if eval_all
            Formula.all(eval_all:).sort
          elsif args.installed?
            Formula.installed.sort
          else
            args.named.to_formulae
          end

          if args.variations?
            formulae.map(&:to_hash_with_variations)
          else
            formulae.map(&:to_hash)
          end
        when :v2
          formulae, casks = T.let(
            if eval_all
              [
                Formula.all(eval_all:).sort,
                Cask::Cask.all(eval_all:).sort_by(&:full_name),
              ]
            elsif args.installed?
              [Formula.installed.sort, Cask::Caskroom.casks.sort_by(&:full_name)]
            else
              T.cast(args.named.to_formulae_to_casks, [T::Array[Formula], T::Array[Cask::Cask]])
            end, [T::Array[Formula], T::Array[Cask::Cask]]
          )

          if args.variations?
            {
              "formulae" => formulae.map(&:to_hash_with_variations),
              "casks"    => casks.map(&:to_hash_with_variations),
            }
          else
            {
              "formulae" => formulae.map(&:to_hash),
              "casks"    => casks.map(&:to_h),
            }
          end
        else
          raise
        end

        puts JSON.pretty_generate(json)
      end

      sig { params(formula_or_cask: T.any(Formula, Cask::Cask)).returns(String) }
      def github_info(formula_or_cask)
        path = case formula_or_cask
        when Formula
          formula = formula_or_cask
          tap = formula.tap
          return formula.path.to_s if tap.blank? || tap.remote.blank?

          formula.path.relative_path_from(tap.path)
        when Cask::Cask
          cask = formula_or_cask
          tap = cask.tap
          return cask.sourcefile_path.to_s if tap.blank? || tap.remote.blank?

          if cask.sourcefile_path.blank? || cask.sourcefile_path.extname != ".rb"
            return "#{tap.default_remote}/blob/HEAD/#{tap.relative_cask_path(cask.token)}"
          end

          cask.sourcefile_path.relative_path_from(tap.path)
        end

        github_remote_path(tap.remote, path.to_s)
      end

      sig { params(formula: Formula).void }
      def info_formula(formula)
        specs = []

        if (stable = formula.stable)
          string = "stable #{stable.version}"
          string += " (bottled)" if stable.bottled? && formula.pour_bottle?
          specs << string
        end

        specs << "HEAD" if formula.head

        attrs = []
        attrs << "pinned at #{formula.pinned_version}" if formula.pinned?
        attrs << "keg-only" if formula.keg_only?

        puts "#{oh1_title(formula.full_name)}: #{specs * ", "}#{" [#{attrs * ", "}]" unless attrs.empty?}"
        puts formula.desc if formula.desc
        puts Formatter.url(formula.homepage) if formula.homepage

        deprecate_disable_info_string = DeprecateDisable.message(formula)
        if deprecate_disable_info_string.present?
          deprecate_disable_info_string.tap { |info_string| info_string[0] = info_string[0].upcase }
          puts deprecate_disable_info_string
        end

        conflicts = formula.conflicts.map do |conflict|
          reason = " (because #{conflict.reason})" if conflict.reason
          "#{conflict.name}#{reason}"
        end.sort!
        unless conflicts.empty?
          puts <<~EOS
            Conflicts with:
              #{conflicts.join("\n  ")}
          EOS
        end

        kegs = formula.installed_kegs
        heads, versioned = kegs.partition { |keg| keg.version.head? }
        kegs = [
          *heads.sort_by { |keg| -keg.tab.time.to_i },
          *versioned.sort_by(&:scheme_and_version),
        ]
        if kegs.empty?
          puts "Not installed"
          if (bottle = formula.bottle)
            begin
              bottle.fetch_tab(quiet: !args.debug?) if args.fetch_manifest?
              bottle_size = bottle.bottle_size
              installed_size = bottle.installed_size
              puts "Bottle Size: #{disk_usage_readable(bottle_size)}" if bottle_size
              puts "Installed Size: #{disk_usage_readable(installed_size)}" if installed_size
            rescue RuntimeError => e
              odebug e
            end
          end
        else
          puts "Installed"
          kegs.each do |keg|
            puts "#{keg} (#{keg.abv})#{" *" if keg.linked?}"
            tab = keg.tab.to_s
            puts "  #{tab}" unless tab.empty?
          end
        end

        puts "From: #{Formatter.url(github_info(formula))}"

        puts "License: #{SPDX.license_expression_to_string formula.license}" if formula.license.present?

        unless formula.deps.empty?
          ohai "Dependencies"
          %w[build required recommended optional].map do |type|
            deps = formula.deps.send(type).uniq
            puts "#{type.capitalize}: #{decorate_dependencies deps}" unless deps.empty?
          end
        end

        unless formula.requirements.to_a.empty?
          ohai "Requirements"
          %w[build required recommended optional].map do |type|
            reqs = formula.requirements.select(&:"#{type}?")
            next if reqs.to_a.empty?

            puts "#{type.capitalize}: #{decorate_requirements(reqs)}"
          end
        end

        if !formula.options.empty? || formula.head
          ohai "Options"
          Options.dump_for_formula formula
        end

        caveats = Caveats.new(formula)
        if (caveats_string = caveats.to_s.presence)
          ohai "Caveats", caveats_string
        end

        return unless formula.core_formula?

        Utils::Analytics.formula_output(formula, args:)
      end

      sig { params(dependencies: T::Array[Dependency]).returns(String) }
      def decorate_dependencies(dependencies)
        deps_status = dependencies.map do |dep|
          if dep.satisfied?([])
            pretty_installed(dep_display_s(dep))
          else
            pretty_uninstalled(dep_display_s(dep))
          end
        end
        deps_status.join(", ")
      end

      sig { params(requirements: T::Array[Requirement]).returns(String) }
      def decorate_requirements(requirements)
        req_status = requirements.map do |req|
          req_s = req.display_s
          req.satisfied? ? pretty_installed(req_s) : pretty_uninstalled(req_s)
        end
        req_status.join(", ")
      end

      sig { params(dep: Dependency).returns(String) }
      def dep_display_s(dep)
        return dep.name if dep.option_tags.empty?

        "#{dep.name} #{dep.option_tags.map { |o| "--#{o}" }.join(" ")}"
      end

      sig { params(cask: Cask::Cask).void }
      def info_cask(cask)
        require "cask/info"

        Cask::Info.info(cask, args:)
      end

      sig { params(title: String, items: T::Array[NameSize]).void }
      def print_sizes_table(title, items)
        return if items.blank?

        ohai title

        total_size = items.sum(&:size)
        total_size_str = disk_usage_readable(total_size)

        name_width = (items.map { |item| item.name.length } + [5]).max
        size_width = (items.map { |item| disk_usage_readable(item.size).length } + [total_size_str.length]).max

        items.each do |item|
          puts format("%-#{name_width}s %#{size_width}s", item.name,
                      disk_usage_readable(item.size))
        end

        puts format("%-#{name_width}s %#{size_width}s", "Total", total_size_str)
      end

      sig { params(formulae: T::Array[Formula], casks: T::Array[Cask::Cask]).void }
      def print_sizes(formulae: [], casks: [])
        if formulae.blank? &&
           (args.formulae? || (!args.casks? && args.no_named?))
          formulae = Formula.installed
        end

        if casks.blank? &&
           (args.casks? || (!args.formulae? && args.no_named?))
          casks = Cask::Caskroom.casks
        end

        unless args.casks?
          formula_sizes = formulae.map do |formula|
            kegs = formula.installed_kegs
            size = kegs.sum(&:disk_usage)
            NameSize.new(name: formula.full_name, size:)
          end
          formula_sizes.sort_by! { |f| -f.size }
          print_sizes_table("Formulae sizes:", formula_sizes)
        end

        return if casks.blank? || args.formulae?

        cask_sizes = casks.filter_map do |cask|
          installed_version = cask.installed_version
          next unless installed_version.present?

          versioned_staged_path = cask.caskroom_path.join(installed_version)
          next unless versioned_staged_path.exist?

          size = versioned_staged_path.children.sum(&:disk_usage)
          NameSize.new(name: cask.full_name, size:)
        end
        cask_sizes.sort_by! { |c| -c.size }
        print_sizes_table("Casks sizes:", cask_sizes)
      end
    end
  end
end
